fix: publish-html --requires-auth never actually gated with OTP - #56
Open
mayoalexander wants to merge 559 commits into
Open
fix: publish-html --requires-auth never actually gated with OTP#56mayoalexander wants to merge 559 commits into
mayoalexander wants to merge 559 commits into
Conversation
The pre-push guard caught this, which is what it is for — an unindexed command is one an agent cannot discover, so it may as well not exist. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qar7NcLqNhHVcUbxrkQccW
…s no longer unbuilt Adds `iris senders prefer` / `bind --primary` to the how-to, and says the thing that is easy to get wrong: `default` picks the default IDENTITY, `prefer` picks that identity's TRANSPORT — two different questions with adjacent names. Also corrects a stale "not yet built: SN-2" line; the backfill shipped. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qar7NcLqNhHVcUbxrkQccW
…ntity routerSend gains `sender`, and `mail send` exposes it. --sender and --from answer the same question in opposite directions: --from is a raw address nothing has checked, taking the unrouted bridge path; --sender is a registered identity the API verifies and routes on. Passing both is an error rather than letting whichever branch runs first decide, and --sender with --attachment/--cc is refused because that path cannot read a channel binding. Deliberately NOT on `imessage send`: the bridge sends from whatever account Messages.app owns, so the flag could not honour itself — and that command falls back to local AppleScript when the router refuses, which would send anyway. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qar7NcLqNhHVcUbxrkQccW
Says the two constraints that are not guessable: an unverified sender is refused rather than downgraded, and --sender needs a lead because the ad-hoc handle path bypasses the channel bindings. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qar7NcLqNhHVcUbxrkQccW
… exception A campaign step declares its own channel, so the sender preference does not apply there — deliberately, since the step author said "email" and a preference must not override that. Which means a mismatch fails at delivery instead of at configuration time. Says so, and points at the command that asks the question early. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qar7NcLqNhHVcUbxrkQccW
The list an apple_mail binding asserts. Until the bridge could enumerate accounts, that assertion was unfalsifiable: verification confirmed the bridge answered, not that the address was a real account. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qar7NcLqNhHVcUbxrkQccW
…aveat was wrong This file said an unknown Mail.app account would send from the default silently. It never did: the bridge's lookup threw -1700 and every Apple Mail send naming a from-address failed outright — the binding path had never worked. The caveat described a plausible failure instead of the real one, which is why it survived unexamined; it read as a known limitation rather than a bug. Documents `iris mail accounts`, that binding and sending both refuse an address Mail.app lacks, and the one claim still outstanding: the account existing is not the same as Mail.app applying it, which only a received header settles. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qar7NcLqNhHVcUbxrkQccW
… way to correct it (#180584)
There was create-list and nothing else. A typo or a duplicate list was permanent from the CLI,
while ITEMS had both delete-item and restore-item. The asymmetry was the bug: a command that
creates structure with no command to correct it means every mistake is forever.
Both endpoints existed the whole time — BloqListController::update and ::destroy, routed as
PATCH /user/{userId}/bloqs/list/{listId} and DELETE /user/bloqs/list/{listId}. Only the surface
was missing, so this is a CLI addition and nothing else.
Hit for real today: `iris bloqs create` silently seeds Ideas/Todo/In Progress/Completed/Daily
Diary and its output says only {success, id, name}, so setting up a client project (anomalyco#601, GTC
MediGuide) produced TWO Todo lists, two In Progress and two Completed — on a board whose first
reader would reasonably ask which one is real. Now cleaned up with the new command.
delete-list is deliberately NOT a mirror of create-list. Creating a list costs nothing; deleting
one takes ITS ITEMS WITH IT. So it:
- counts the items first when --bloq-id is given, and REFUSES a non-empty list without --force,
naming the count — a number is what turns "delete list 1964" from a guess into a decision
- WARNS when it could not count, rather than proceeding quietly. A silently skipped safety
check reads exactly like a check that passed, which is the failure mode this codebase keeps
finding.
Verified against the live board rather than asserted:
delete-list 1964 --bloq-id 601 -> success, items_removed 0
delete-list 1974 --bloq-id 601 -> REFUSED: "has 6 item(s)"
delete-list 9999999 (no bloq-id) -> warns the count was NOT checked
Still open in #180584: `bloqs create` should report the lists it seeds, or take
--no-default-lists. Printing them would have prevented the duplication entirely.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011wLxn8v7hD3rud1G17YjNk
`resolve` APPENDS a resolution block, so a bug closed more than once carries every stamp it has ever had. Both readers used `String.match()` without /g, which returns the FIRST match — the OLDEST stamp. That breaks the remediation path for the bug fixed in fe89daf. When a close stamps the wrong commit, the correction is to re-close with the right one — and the correction was invisible. #180525 was mis-stamped a8a9cc45 (fl-eco-docker, an unrelated repo), corrected TWICE to ebbf1f7, and still displayed a8a9cc45 on both `bug list` and `bug show`. The wrong hash was what everyone read, and nothing you could type would change it. A record you cannot correct is worse than one that was never written: it looks authoritative and it is wrong. Extracted `latestFixCommit()` (matchAll, take the last) and used it at both call sites. Four tests, including the real #180525 content and a guard that prose mentioning a hash is not mistaken for a stamp. Verified against production: #180525 rendered `✓ FIXED a8a9cc45` before and `✓ FIXED ebbf1f7` after, same ticket, same data. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0121oCnNCeZBewSyiQSQ8bin
… (#180633)
`iris bloqs list` hits /api/v1/user/{id}/bloqs, which excludes system bloqs — agent workspaces
and `app:*` client dashboards. On this account that silently withheld thirteen boards,
including app:pathways-dashboard, app:pathways-clinical-doc, app:drex-dashboard,
app:experience-art-dashboard, app:moody-beauty-dashboard and four agent Workspace boards.
Hiding them by default is right; a project picker should list things a human made. Saying
nothing about it is not. `iris bloqs get 11` returns System Data with its two lists and
`iris bloqs list --limit 500` does not contain it — so a board you can open by ID is absent
from the list with no indication, which is indistinguishable from not having access to it.
That is precisely how a WORKING access grant on bloq anomalyco#600 read as a failed one earlier today:
granted, checked the list, did not see it, concluded the grant had not worked.
Adds --all (include_system=1) and --type, and prints a dim note whenever neither is set.
The note is deliberately not a count. The index endpoint does not report how many rows it
withheld, and printing a number the server never sent would re-introduce the same class of
problem this fixes. Naming the flag is honest; guessing the total is not.
Needs the paired fl-api change (8a8cd43a) — before it, ?type=system returned 0 of 13 because
the hardcoded type filter was ANDed ahead of the declared one.
bun turbo typecheck: 12/12 pass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011wLxn8v7hD3rud1G17YjNk
`report [title..]` is a GREEDY array positional joined with spaces, so when a callers quoting
collapses every loose token slides into the title and the report is filed anyway. Reproduced
against the same builder shape:
["report","search returns 0","daycare","Hutto","--json","TX","78634"]
-> title "search returns 0 daycare Hutto TX 78634", json=true
which is exactly the shape stored on #180697. Nothing errored. That is the actual defect — not
that reports break, but that a broken one arrives LOOKING COMPLETE, so four were filed in a row
before anyone noticed.
CORRECTION to the root cause in #180713: apostrophe stripping and repro commands truncated at
the first quote are NOT this tool. They happen in the callers shell before argv exists.
Counter-example, filed by this same command earlier today: #180691 stores "person\x27s push" with
the apostrophe intact, and #180633 kept its quotes. Anyone hunting for a sanitizer in this file
would have found nothing, which is why it is worth writing down.
So the guard aims only at what argv can still see:
- a flag string absorbed into the title (--description, --severity, --command, --error,
--json, --bounty) or a positional that begins like a flag -> refuse, name the token, show
the assembled title, print the correct invocation
- a title over 220 characters -> refuse; that is body text, not a headline
Adds --title, the form that cannot absorb its neighbours. The non-interactive error has been
telling people "--title is required" for some time while no such flag existed. yargs folds it
into the same array as the positional (verified: --title x yields ["x"]), so it needs no
separate branch.
And the part that generalises, because collapsed quoting CANNOT be reliably detected — by the
time yargs is done, --json has been eaten as a flag and the leftovers look like an ordinary
title: the success output now echoes the TITLE THAT WAS STORED. The confirmation used to print
only "submitted" and an item id, which is how four corrupted reports got past their own author.
Verified from source: absorbed-flag case refuses, 383-char case refuses, ordinary report still
files and now shows its stored title. bun turbo typecheck 12/12.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011wLxn8v7hD3rud1G17YjNk
Login only ever said "Authenticated (user 193)". A bare id is not an answer when someone legitimately holds two accounts: here the laptop CLI authenticates as one user and the MCP connector as another, and a board owned by the other is not reported as locked — it is reported as "Bloq not found". You cannot notice an account mismatch you were never shown, and today that cost a wrong duplicate-account diagnosis before anyone checked the ids. Prints email + id + admin flag, and WHERE the credential came from: a stale IRIS_API_KEY in the environment silently outranks `iris auth login`, so "I logged in as X but everything acts as Y" was previously invisible. Resolved server-side from the bearer in hand (GET /api/v1/auth/whoami, fl-api d11fc8dc) rather than read back from local config — local config is exactly the value that lies when a stale env var is in play, so it reports the account the API will actually act as. When the API cannot confirm the credential it says so instead of falling back to the stored id; a confident wrong account is the bug being fixed, not a missing one. Verified against production: 200 with the right identity, 401 unauthenticated, and the failure path exercised while the endpoint was still 404. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0121oCnNCeZBewSyiQSQ8bin
typecheck has been red on main since 18:36Z. `iris auth whoami` shipped in 7a90f491e without
regenerating the capability index, and capabilities:check is a required step:
capabilities.json is STALE — agents cannot discover what is not indexed.
missing from the index (1): command:auth whoami
That failure message is the point of the check. The index is how agents find commands, so a
command that exists but is not indexed is a command no agent will ever call — it works when a
human types it and is invisible to everything else.
Regenerated locally where the workspace IS present, so playbooks and skills were compared too.
CI can only see commands and how-tos (it warns "83 playbook/skill entries were NOT checked —
set IRIS_PROJECT_ROOT"), which means a regeneration run in the wrong environment can silently
drop project content. Checked the diff for exactly that: 11 insertions, 3 deletions, counts
1199 -> 1200 and 1319 -> 1320, the parent auth haystack gaining "whoami", and the new entry.
Nothing removed.
bun.lock is also dirty in this checkout from another session and is deliberately not included.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011wLxn8v7hD3rud1G17YjNk
…#180713)
Adding `--title` alongside the `[title..]` positional made the help WORSE. yargs folds an option
and a positional of the same name into one entry, so the option description won and the help
read:
title bug title (unambiguous alternative to the positional) [array] [default: []]
which describes the flag while sitting in the positional slot, and no separate --title line ever
appears. Someone reading that learns neither how to pass a title nor that the flag exists — a
net loss against the original "short bug title".
Both describes now carry the same text, which reads correctly wherever yargs decides to print
it, and names the flag:
title short bug title — quote it, or pass --title "..." [array] [default: []]
Verified by running `bug report --help` against a checkout with workspace deps, not by reading
the source — the merge behaviour is the whole point and is only visible in the rendered output.
capabilities.json is unaffected: it indexes command describes, not positional ones (checked).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011wLxn8v7hD3rud1G17YjNk
The course is real and has been for months — Course/CourseChapter/ CourseProgress/CourseCertificate, scored server-side, verifiable by anyone at /p/verify-certificate without an account. What was new is that a Bounty OS visitor can now reach it. The section spends most of its words on what certification is NOT, because all three confusions are expensive: - it is not a gate (nextStep ranks it BELOW an unclaimed balance on purpose — telling somebody to sit a quiz while their money sits unmentioned is how a dashboard loses trust) - it is not an agreement (same shape, different thing: a signed document has a ledger, an audit trail and revocation that a quiz does not) - the answer key never leaves the server, so a new question path goes through HunterTraining::publicQuestions() or it ships its own answers Plus the two failures worth naming: a per-path proxy that 404s same-origin while the upstream is fine, and an unseeded environment where `training` is null and the section is omitted rather than emptied. Refs #180702 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0118r7ZPdSYw7oymTNBoUiqF
… (#180704, #180706)
get_pipeline_summary returns { pipeline: [{stage,count,total_value}], totals: {cases,value} }.
The CLI read data.total_cases, data.stages[].name and .value — none of which exist — so a
system holding 2,156 cases and $17.2M reported "0 cases | $0" on both `pipeline` and `status`.
The data was reachable the whole time: `integrations exec servis-ai list_cases` returns 2,129.
Those two commands are the first thing anyone runs, so a working system looked dead.
Normalised into readPipeline() so both callers agree, with the old field names kept as
fallbacks. status also surfaces stage count and audit_flag_count now that they are free.
`audit` printed fc.patient_name in preference to the case ID, putting patient names into
terminal scrollback, screen-shares and recordings — and it is the command most likely to be
demoed on a client call. Default is now the case ID; names require an explicit --names flag.
pipeline 0 cases | $0 -> 2156 cases | $17,241,820.12
audit "<patient name>: ..." -> "CAS112824: ..." (--names to opt in)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wc96FNjrbQVzzL3EKEPSNo
…#180735)
`iris opportunities list --json` emitted INVALID JSON, cut mid-value at an exact 64KiB boundary,
with exit 0 and nothing on stderr. Every automated consumer therefore either failed to parse the
prefix or — worse — parsed it and acted on a partial list.
index.ts ends with process.exit(), which DISCARDS whatever stdout still has buffered when stdout
is a pipe. Interactive use never shows it: TTY writes are synchronous. Only scripts break, which
is the mode nobody is watching.
MEASURED ON THE COMPILED BINARY (source barely reproduces it — 1/10 — which is exactly why the
binary is the thing to test):
before: 6/6 truncated at 65536
exit-site drain: 1/10 truncated <- did not work
stdout.write wrapper: 2/12 truncated <- did not work
writeJson at the site: 0/15
TWO WRONG FIXES FIRST, both of which LOOKED right from source. Probing bun directly explains
why neither could ever have worked:
process.stdout.write("x".repeat(300_000))
process.stdout.writableLength -> 0 (nothing to poll)
process.stdout.writableNeedDrain -> false
process.stdout.write("", cb) -> cb fires SYNCHRONOUSLY (barrier is a no-op)
console.log(...) -> does NOT route through process.stdout.write at all
So the pending bytes are not observable at the exit site, and console.log cannot be wrapped,
counted or drained from outside. A REAL write callback does fire after the flush — which is why
writeJson() in iris-api.ts works, and why it is the only available mechanism. The existing note
in index.ts said the fix belongs at the write site; this confirms it with measurements and
leaves the reasoning where the next person will look.
So: 622 pretty-printed `console.log(JSON.stringify(x, null, 2))` call sites across 134 files are
now `await writeJson(x)`. The transformation is OUTPUT-IDENTICAL by construction — writeJson
emits JSON.stringify(value, null, 2) + "\\n", byte for byte what console.log of the same
expression produced. Only the flushing changes.
Compact `JSON.stringify({success:false,...})` calls are deliberately left alone: they are small
error objects that cannot approach the pipe buffer, and converting them would be churn.
Four sites landed in sync functions and were caught by typecheck, not by review — the reason a
mechanical sweep of this size is safe to attempt at all. Each fixed properly rather than
reverted: a forEach became for..of (a sync callback cannot await, so the payload would have gone
out unflushed again), and printResult/renderLocalUsage became async with their single callers
awaited.
Verified on a locally compiled binary: 0/15 on the reported command, and bug list (161KB),
agents list (145KB) and bloqs list all parse. typecheck 12/12. capabilities.json unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011wLxn8v7hD3rud1G17YjNk
…ate shipped fl-api #180702 moves verified-but-unreleased money out of `owed` into a new `held_cents`. This board only rendered `owed`, so the instant that deployed, Rashad ($39) and Flo ($18) went from "owed $39.00 / $18.00" to "owed $0.00" with nothing on the row to say the money still existed. That is wrong in a quieter and worse direction than the overstatement it replaced. $0.00 owed reads as "nothing pending" or "already paid" — an operator scanning this board would conclude two hunters were square when $57 of their verified work was sitting held. Rendered as its own column rather than folded back into `owed`, because they are different facts: one is money that moves if you ask for it, the other is money waiting on something the hunter has to do. The column only appears when somebody actually has some, so the ordinary board stays a four-column read, and the legend says what held MEANS — a board that shows a number nobody can interpret has moved the problem rather than fixed it. Verified by running the real production payload through this exact expression: $39.00 and $18.00 render, the three hunters with nothing show an em dash. Refs #180702 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0118r7ZPdSYw7oymTNBoUiqF
d3d26d81 blocks hive run / exec / script / push / deploy / swarm / demo on the iris-exec path. The how-to still read as though `hive run` works everywhere, and the person most likely to hit the new refusal is someone following this page from Claude. Says which commands are refused, which still work (nodes list/show, tasks, status, logs, peers, connections, queue, doctor), and WHY — the restriction is about trust, not capability. A human typing `iris hive run` has intent; a model that just read an email does not. Deliberately NOT changing `iris hive run --help`: the CLI is not restricted, and a warning in its help would be wrong for the terminal, which is where that help is read. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0121oCnNCeZBewSyiQSQ8bin
Observed in production 2026-08-17. Asked to fetch projects, the model called the MCP with `iris bloqs list --json` — exactly as a human would type it — and got back `Unknown command "iris"`. It then told the user it could not access the IRIS platform. The tool taught the model to deny a capability it had. The MCP spawns the iris binary directly with the argv it is handed, so a leading "iris" lands in argv[0] where a subcommand belongs. Both forms are now accepted. Expecting every model to remember that this one interface wants the binary name omitted is a convention we would have to re-teach on every model swap — and the default model changed three times in two days. Tests pin both forms, that a value merely CONTAINING "iris" is untouched (only argv[0] is a binary name), and that quoted multi-word arguments survive the shift. One test in the first draft asserted that an unknown command is still rejected. It passed for the wrong reason: the guard is gated on `knownCommands.size > 0` and the registry is not loaded in a unit context, so it never ran. Replaced with an assertion that actually exercises something, and the reason is written down — a test that claims coverage it does not have is worse than no test. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… slicing it Observed 2026-08-17: `bloqs get 544 --json` returned 68KB, the MCP sliced it at 100KB-minus-nothing with '...(truncated)', and the agent reported it could not access the IRIS platform. A JSON payload cut mid-object is unparseable, so the model lost the data AND every route back to it — and read the whole thing as a failure. Claude's own harness handles this better: write the full result to a file, tell the model to use offset/limit/jq. That turns a dead end into an artifact. This does that and one thing more. It returns an OUTLINE of the payload inline — top-level keys with types, plus the key shape of the first element of any array. In the transcript that motivated this, the very next thing Claude did after the overflow was `jq keys` to discover the shape. The outline answers that up front, so the model can write a useful filter on its first attempt instead of its second. The message is written as instructions, not as an error, and says explicitly that the command SUCCEEDED. That sentence is load-bearing: the failure being fixed is not 'output too big', it is an agent concluding the platform is down and telling the user so. It also suggests re-running something narrower, because reading a 68KB file is rarely the best answer when a specific list id would do. Truncation stays as the fallback when the spool cannot be written — a degraded answer beats no answer. 17 tests: the complete payload survives byte-for-byte, the outline names keys and element shapes without a round-trip, jq examples for JSON and grep/head for text, and the success wording is asserted rather than assumed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… (#180715)
The good search already existed. `bloqs items <bloq-id> --search ... --include-all` fans out
across bloq + Obsidian + Drive, returns snippets and reports per-source health. It just required
a board id — which you do not have at the moment you are searching, because finding it is why
you are searching.
Worse, the empty-result hint on `iris search` pointed AT that command:
Widen the net: iris bloqs items <bloq-id> --search "..." --include-all
offering the one thing the reader cannot run, at exactly the moment they cannot run it.
`iris search` now takes --include-all and --source. The hint points at itself:
Widen the net: iris search "propulsion" --include-all
and disappears once the fan-out is already on, rather than suggesting what you just did.
The bloq source is deliberately EXCLUDED from the fan-out here: the cross-board content search
this command already runs covers it without a board id. Including it would reintroduce the
exact requirement that made the engine unreachable.
Source outcomes are always reported, in both text and --json (source_outcomes), because a
source that ERRORED must not be indistinguishable from one that found nothing — the same rule
federated-search.ts already states for skipped sources.
Verified from source: the default path prints the corrected hint; --include-all prints the
"Obsidian & Drive" section plus the health line "obsidian 0 · drive 0" and drops the hint.
typecheck 12/12.
Nothing was rebuilt — this is a front door on an engine that was already working.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011wLxn8v7hD3rud1G17YjNk
…laces (#180717)
mcp-serve.ts ships a hand-written subcommand list in the tool description. It is the first thing
every MCP agent reads, and large parts of it name commands that do not exist. Verified each line
against the live CLI rather than trusting it:
memory: advertised "store, search, query, entities"
iris memory store -> Error: Did you mean show?
iris memory query -> Error: Unknown argument: query
iris memory entities -> Error: Unknown argument: entities
memory is an ALIAS for bloqs; it self-describes as "manage knowledge bases (bloqs)".
There is no memory store and never was.
outreach: advertised "send, campaigns, templates, status"
real: list, show, create, update, delete, apply, approve
ZERO overlap. Campaigns live under a separate `outreach-campaign` command and per-lead
steps under `outreach-send`, which is what the advertised names were reaching for.
workflows: "cancel" does not exist
invoices: "get" does not exist — the verb is `show`
brands: "get" does not exist — the verb is `show`
pages: "update", "sync" and "history" do not exist — they are `set` and `versions`
integrations: "list", "status", "test" do not exist — they are list-connected /
list-available / list-integrations / list-tools
mail: "inbox" does not exist — it is `accounts`
partials: "update" does not exist — it is `set`
leads: "gate" does not exist — it is payment-gate / gate-all / update-gate
The Examples block was wrong the same way: it told agents to run
`iris outreach send --lead 12345 --channel email`, which is not a command.
Every line is now taken from the actual command tree. This matters more than an ordinary docs
fix: an agent that reads "memory store" will try it, get "Did you mean show?", and conclude the
feature is broken rather than absent — the wrong map produces confident wrong behaviour, not a
visible error.
Worth a follow-up: this list is hand-maintained beside a capabilities.json that is GENERATED
from the same command tree and already indexes 1,200 commands. Generating it would make this
class of drift impossible instead of merely fixed.
typecheck 12/12.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011wLxn8v7hD3rud1G17YjNk
On every other command in this CLI `search` means "search my data". Here it meant "scrape
Google Maps", so `iris venues search "day care"` answered with Care.com, a Yelp page for
Honolulu and a Baltimore directory — none of them the caller.s venues, and nothing in the
output saying the source was the open web. The noun promised one thing and delivered another.
Default is now the venue list you own. The web lookup is a real capability and is NOT removed —
it moves behind --web (the existing `scrape` alias still reaches it), and the local result
footer names it explicitly rather than quietly owning the noun:
Search the open web instead: iris venues search "studio" --web
Caught while wiring it: the venues index reads `query`, NOT `search` — `venues list` proves it.
Sending the wrong parameter name would have returned EVERY venue and looked like a working
search, which is the same failure shape as the bug being fixed.
Verified from source: `venues search "studio"` now returns Blue Mark Studios, The Beaumont
Studios, ANOMALY Studios and Black Swans Studio — real records with ids and cities.
typecheck 12/12.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011wLxn8v7hD3rud1G17YjNk
…t (#180537, #180539) `iris bounty create` set is_public: true unconditionally. Creating a campaign and offering it to creators were the same act, so terms you meant to review were already live the moment the command returned — anomalyco#698 stood in front of creators carrying $11,998 that way. Publishing is now opt-in via --publish; without it you get a draft. The trap underneath is that the flags MIX UNITS: --rate-per-mille is CENTS while --budget, --reward-tiers and now --amount are DOLLARS. Nothing in the output ever restated them, so a 100x slip was invisible until it was live. Every amount is now echoed back converted, grouped ("$10,000.00", not "$10000.00" — an order of magnitude is one glyph in a run of zeros, and it is the error worth catching), on a real create as well as a dry run. A number you first see after publishing is a receipt, not a check. --dry-run prints that preview and exits without touching the API. --amount (#180539) fills the other half: gig/fde/task are priced by FixedAmountCalculator, and proposal_metadata.amount.fixed_cents is the only one of its four sources that exists at creation time — the rest are role/proposal records this POST has not created yet. Without it every engagement bounty was created worth nothing and repriced by hand. Verified from source: draft by default, is_public true only with --publish, $750 gig amount reaching proposal_metadata, and the mixed-unit preview reading in dollars. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X6qNMrGDtt1Dr2gMC6PKer
The reported symptom — `bounty create --json` printing an application-form schema instead of the error — was already guarded before the payload is composed, and has been since the command was written on Jul 12 (74e6790). The reporter's binary predated it; the exact command now prints exactly the error they asked for, exit 2. The sentence under the report is the live defect: "Which is a perfect error message — it is just unreachable under --json, the mode a script would use." handleApiError never looked at --json. Every failure branch wrote clack prose to STDERR and nothing at all to stdout, so a JSON consumer got an empty stream on 401, 402, 403, 404, 422 and 500 alike — byte-identical to a successful call that found nothing. That is how the reporter read a failure as a creation and went looking for a bounty that did not exist. Same shape as everything in ARCHITECTURE_GAPS.md: a signal that cannot distinguish "it broke" from "there was none". Now every branch emits one parseable object on stdout — {success:false, error, status, action} — keeping the sanitisers (#57646 model errors, #162342 SQL leaks) and carrying the actionable parts structured rather than flattened into prose: 422 field errors stay a map, 402 forwards the whole remediation payload with its checkout_url and cli_command. The human path is untouched. requireAuth had it worse: the near-universal `if (!token) return` exited ZERO with empty stdout, so an unauthenticated --json run was indistinguishable from a successful empty one. It now answers in JSON and exits non-zero. Read from argv rather than threaded through a parameter: ~760 call sites across ~104 files, all `handleApiError(res, action)`. An opt-in argument fixes only the sites someone remembers, and the complaint is precisely that scripts hit the unfixed path. Verified against the built binary: `iris bounty stats 999999999 --json` went from empty stdout to a jq-parseable object. 12 new tests; the platform suite's 23 failures are unchanged from HEAD (pre-existing), 399 -> 411 passing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X6qNMrGDtt1Dr2gMC6PKer
Lockfile drift, surfaced by a local build — package.json and the release tag were both already 1.3.180 while bun.lock still recorded 1.3.162. Not part of the bounty/--json fixes; committed separately so those two diffs stay exactly what they claim to be. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X6qNMrGDtt1Dr2gMC6PKer
Junaid's Aug 23 call named "the one big thing that is missing is actually KPIs" as the gap in the RevOps build. Adds objectives + key results (quarterly, goal-scoped) and a separate KPIs dataset (ongoing steady-state metrics, no quarter attached) — same Atlas schema-driven pattern as the mentions dataset from #182118. iris okr objectives list/create/show/update iris okr kr add/update iris okr status — dashboard across all tracks iris kpi list/create/update/show Live-verified end-to-end against production with placeholder records, then cleaned up. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HzGwGaksRciQ6AWi4y7WW3
…shes doctor [name]: catches the version/steps trap where a playbook has real "### step:" blocks in its body but frontmatter's `version` isn't EXACTLY 2 (parsePlan does `fm.version === 2 ? 2 : 1`, an exact-match not a floor) -- steps silently never execute and `run` falls back to a raw text dump with no warning. Also surfaces shadow copies (Skill now tracks every discovered location for a name via Skill.locations()/Skill.duplicates(), not just the first-found winner) plus the existing validatePlan() issues, in one sweep over every playbook. verify <name>: replaces the manual curl/grep/json-parse loop after a publish with one command -- local file parses, API registry has the row, registered content matches the local file (a publish 200 says the request succeeded, not that the body sent was current), and the live public page actually returns 200 when scope is public. Found 3 live, currently-public playbooks broken by the exact bug doctor targets (agentic-loop, x-ads, iris-hive) -- fixed at the data layer (playbook frontmatter, separate commits/repo) and confirmed clean with verify. Filed #182230 for the underlying exact-match coercion in executor.ts, which is unchanged here. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DkFsjYp3XWwDQBeTH6cEqi
Export fmtPct from platform-okr.ts so it's testable in isolation, and lock in the null-target, zero-target, no-reading-yet, and real-0% cases — the em-dash vs. 0% distinction is the one this display logic is easiest to get wrong silently. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HzGwGaksRciQ6AWi4y7WW3
Two things a concurrent rebase dropped, recovered: 1. THE HIVE REGISTRATION. `iris hive vault` was gone — platform-hive.ts no longer imported or registered VaultCommandExport, so only the top-level `iris vault` survived. That is the placement specifically asked for: vault belongs next to `hive fs`, the primitive it is built on. Re-added; both paths now resolve and both are in the capability index. 2. THE SELF-NODE FIX (was 317b7d6, dropped from HEAD entirely — the code had no detectLocalNodeId at all). `hive vault status` counted the local node's blobs as "this machine" AND listed that same node under "could not reach", in one breath. On a two-node mesh that is half the fleet described wrongly. detectLocalNodeId() now excludes self before dialling, using the daemon's own node_id rather than os.hostname() — macOS rewrites the hostname on every mDNS collision, so a hostname comparison matches the wrong machine or none. Also reindexes capabilities: 1323 commands · 1517 capabilities. The pre-push hook was right to block on this — a capability that exists but is absent from the index is undiscoverable, which is the same defect family as a node advertising what it cannot do. Verified: typecheck clean (0 errors across the workspace), 31 vault tests pass, `iris hive vault --help` lists all six subcommands, and both `iris hive vault` and `iris vault` appear in capabilities.json. NOTE FOR WHOEVER REBASES NEXT: this is the second time these changes have been silently dropped by a `pull --rebase` in this repo (26606af, then 317b7d6). Both were recovered by cherry-pick only because a typecheck failure or a stale capability index happened to surface it. Nothing announces the loss. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DezvuWKN7k5zUBtJ44ZZM9
…are gating, brand-correct how-to social Three independent changes recovered from a stash that a parallel session's work had been swept into. Each stands alone: mcp call / mcp tools (#182089) — an MCP server was reachable by a human in an MCP client and by nothing else: not a playbook, not an agent, not a script. IRIS was already an MCP client; it had no verb that invoked a tool. MCP.callTool THROWS on failure rather than returning undefined, and checks isError separately from transport success — a well-formed response whose payload is an error is not a successful call, and conflating the two is why MCP-backed playbook steps had to be written as `mode: human`. Share gating (allowed-emails / allowed-domains) — threads named-recipient and domain allowlists through apiMakePublic to `iris atlas item share`, so a PHI-classified item can be shared with verified addresses instead of a link anyone holding the URL can open. How-to social (#182088) — the CTA and links followed the docs site rather than the brand. A FREELABEL post told musicians to run `iris how-to view <slug>` (an instruction they cannot follow) and linked to IRIS docs. Nothing errored; the render reported success and the creative belonged to another company. Now --url carries the brand's own link, the CLI incantation is IRIS-only, and a non-IRIS brand with no --url warns loudly instead of silently emitting another company's URL. Verified: typecheck clean, 787 tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HzGwGaksRciQ6AWi4y7WW3
`current / target` is only correct for an INCREASE key result. Applied to a decrease KR it inverts, and the failure is silent and flattering. Seeding real RevOps data surfaced it immediately. A genuine KR — "cut hours of manual effort per campaign", target 1h, sitting at 6h, i.e. its worst possible value, nothing done yet — evaluated 6/1 = 600%, capped to 100%, and rendered in the "complete" colour. It then dragged its objective's average UP: the marketing_ops row read 60% when true progress was 18%. `direction` was already stored on every KR. It was used for one thing: which arrow glyph to print. It never touched the arithmetic. krProgress() now measures each direction on its own terms — decrease as target/current (guarded so current=0, a total elimination, cannot divide to Infinity), maintain as decay from the target in either direction (overshooting a hold-steady KR is a miss, not a win), increase unchanged. It returns null rather than a number when a pair cannot yield a percentage, and the dashboard EXCLUDES those from the average instead of counting them as 0 — "not measured yet" is not "no progress", and folding them together makes an objective look worse than the evidence supports. The same defect existed one layer over in KPIs, which had no direction field at all: "Lead response time", 45min against a 15min target — three times worse than goal — rendered as 300% in the exceeding-target colour. Added direction to the revops_kpis schema (now v2) and threaded it through create/list/update/ show, with an arrow so a reader can tell whether a low number is the goal or the problem. Verified live, not just in tests: marketing_ops 60% → 18%, the decrease KR 100% → 17%, lead response time 300% → 33%, and both move monotonically the right way as readings improve (6h→3h = 17%→33%; 45min→20min = 33%→75%). 15 tests cover the arithmetic, including the two regressions by name. Full suite: 796 pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HzGwGaksRciQ6AWi4y7WW3
…ndiscoverable The pre-push gate caught this, which is the gate working. Four new commands existed and ran, but nothing that searches for a capability could find them, so `iris how-to search` and the agent-facing index would both have reported "no such thing" for features that shipped. 1325 commands · 48 how-tos · 73 playbooks · 73 skills = 1519 capabilities. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HzGwGaksRciQ6AWi4y7WW3
mayoalexander
force-pushed
the
fix/publish-html-requireotp-182059
branch
from
August 24, 2026 20:10
831d15c to
2dfcc6e
Compare
…d the live KPI layer
`iris kpi` was a second KPI store standing next to one that already existed and
was better. `iris bloq kpis 624` holds 19 KPIs, five computing from real data,
each blocked one annotated with WHY, with a gap map (#182060) and build order
(#182075) built on top of it. Mine was global instead of bloq-scoped,
hand-entered instead of computed, and tracked no blocked reasons. Two stores
both called "KPI" is how a number ends up in the one nothing reads, so this
removes mine rather than keeping both.
What genuinely did not exist is an objective with SEVERAL key results under it.
`bloq goals` carries one `target` string and one `--kpi` link. `iris okr` keeps
that job and nothing else — it is now a goal layer, not a measurement layer.
A key result can now REFERENCE a KPI (`--kpi-bloq 624 --kpi k_zrl88kmtov`) and
read its value live instead of having it retyped and left to drift.
The reference is deliberately strict about what it will claim:
- The KPI is the source of truth for the READING. When it has none — 14 of the
19 on anomalyco#624 are blocked — the key result is UNMEASURED and says so, naming the
blocking reason. It does NOT fall back to the KR's own current_value, which
defaults to 0: that fallback rendered a confident "0%" for a metric nobody is
computing, the same defect as the decrease-KR bug one commit earlier, and it
was caught the same way — by pointing it at a real blocked KPI and reading the
output instead of trusting it.
- The TARGET still falls back, because a KPI can declare a goal before it
measures against it.
- A reference to a KPI that no longer exists renders loudly as missing rather
than silently showing stale local numbers.
- Unmeasured and blocked key results are EXCLUDED from an objective's average
and reported as "(+N unmeasured)", never averaged in as zero.
Verified live against bloq anomalyco#624:
MRR 99 / 5000 2% ← live
Logo churn 25% / 5% ↓ 20% ← live, decrease applied
Lead-to-Contact not measured / 60% ← blocked: no population query
crm_ops objective reads "25% avg across 1 KR (+1 unmeasured)"
Note: evolving revops_key_results to carry the reference orphaned its existing
rows (Atlas #181628 — the read path only sees the newest schema version). Rows
were backed up and re-seeded. Anything with real data in it needs that handled
before a schema change, not after.
18 tests here; full suite 799 pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HzGwGaksRciQ6AWi4y7WW3
1324 commands · 48 how-tos · 73 playbooks · 73 skills = 1518 capabilities. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HzGwGaksRciQ6AWi4y7WW3
…ility lives in its flags The haystack was built from command name + aliases + one-line describe. Option names and their describe text were never read, so the index knew what a command was CALLED but not what it could DO. Found by trying to use it: `iris atlas:item make-public` carries --allowed-emails and --allowed-domains, whose entire purpose is gating a shared link to named people or domains. Searching "gate", "allowed emails" or "restrict who can read" returned NOTHING across all 1518 capabilities — the only place that language exists is an option description. The feature shipped hours earlier and was already unfindable. Same failure family as a command that registers but is unreachable, one layer up: a capability nobody can find is one nobody uses. Verified: gate · allowed-emails · allowed-domains · phi · verified now all index against atlas:item make-public. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HzGwGaksRciQ6AWi4y7WW3
`iris playbook items` lets a playbook hold the written SOPs a person follows (as Atlas items, attached BY REFERENCE) alongside the skills an agent runs. The models, controller, CLI and public gallery rendering have all shipped for a while — and it went almost entirely unused. Every playbook's contents were empty, because nothing in the naming said the capability existed. Nothing here changes behaviour. It changes whether anyone can find it: - `items` describe now names what it is for rather than restating the noun, plus an epilogue explaining the two kinds of thing it holds and the by-reference model (edit the Atlas item, every playbook carrying it updates — nothing is copied, nothing goes stale). - `add` describe and --bloq-item/--skill help say what they attach, with two worked --examples. - The empty state teaches the commands instead of only saying "(none)". That is the one moment a person is definitely looking at this surface, and it was being spent on a shrug. Adds how-to `playbook-sops-and-skills` covering the full path: draft an SOP from a transcript, publish it as an Atlas item, attach it, assign roles — including that a raw meeting note is NOT an SOP (it is organised around when things were said, not what someone has to do) and the version-bump semantics that expire acknowledgements. Follow-up for a provisioning verb (fork/seed a team's library from templates) is tracked separately; the container is done, only provisioning is missing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01792epDfkpAJTSXPpK2HmnU
…files (RO-7 #182271) The RevOps KPI layer was built against one revenue model: a B2B SaaS funnel. The customer it was built toward — GTC MediGuide (bloq anomalyco#601) — is TELEHEALTH, where revenue operations means the revenue CYCLE: eligibility, prior auth, coding, claim submission, adjudication, denials, A/R. Not a missing metric, a missing half (RO-6 #182260). Only the client can say which model applies. Waiting blocks the build; guessing wastes weeks in whichever direction is wrong. So the model stops being a prerequisite and becomes a parameter. Both models share one spine — work item -> staged pipeline -> terminal outcome -> reason taxonomy -> recovery motion -> cycle time -> cost ratio. opportunity /claim, won/paid, lost/denied, loss-reason/CARC, re-engagement/appeal, sales-cycle/days-in-A-R, CAC/cost-to-collect. That mapping came from laying the HFMA MAP Keys beside our own gap map, not from analogy-hunting. Ships: - revenue-models.ts — typed profile registry: subscription, payer_billed, cash_pay. Serialisable by design so it can move to a served registry when agents need it. - `iris bloq models [bloqId]` — profiles are discoverable, and it says which one a given bloq is running. - `iris bloq kpis list` filters to the active profile, ALWAYS prints which profile answered, and distinguishes "declared on this bloq" from "DEFAULT — not declared". A default is not a decision, and reporting one as a choice is the same defect as a gate that says "gated" without saying gated to whom. - `--model <key>` previews another profile without mutating the bloq; an unknown key is REFUSED, never silently defaulted, so a typo cannot read as a deliberate choice. - `--applies-to` on `bloq kpis add`; `--all` to see every profile's KPIs. Two safety properties, both tested: - An untagged KPI applies EVERYWHERE. All 19 KPIs on anomalyco#624 are untagged today; if absence meant hidden, enabling this would empty the board — and a metric that vanishes reads as "we don't track that", which is indistinguishable from "we track it and it's fine". - Filtering reports what it set aside rather than quietly shrinking the list. Default is `subscription`, so no existing board changes meaning. Phase 2 (837/835 ingestion, clearinghouse, CARC parsing) stays correctly blocked. Verified live against bloq anomalyco#624. 20 new tests; 613 pass across the CLI suite. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HzGwGaksRciQ6AWi4y7WW3
`mode: playbook` lets a step hand off to another playbook. It has worked since the executor shipped — recursive execution, depth cap of 3, self-reference guard, arg passing, nested run output. Verified end to end with a throwaway parent/child pair. Playbooks in the registry using it: zero. Because nothing in the help text, the docs, or any existing playbook said it was possible, authors wrote "now go and run X" as prose — a chain edge that only exists as an instruction a person has to notice and follow. Adds an epilogue to `iris playbook --help` with the syntax and the limits, and a how-to covering when to chain versus when a child playbook is just a section of the parent wearing a costume. Both state the limits plainly, since each is currently discovered by hitting it: nesting caps at 3, a playbook cannot call itself, INDIRECT cycles are not detected (A→B→A surfaces as a confusing depth error), args are positional so reordering a child's declarations silently changes every caller, and a typo'd child name passes `playbook test` and fails only at run time. Part of #182309, which tracks the missing primitives — branching, fan-out, named args, and a visible dependency graph. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01792epDfkpAJTSXPpK2HmnU
…annot run a stale copy The CLI half of HF-01 + HF-03 (#182275, #182276). Daemon half: bridge d6d2e70. `iris scripts run` now resolves the slug to a sha256 of its content BEFORE dispatching, and sends it in the task config. The node then runs exactly that version or refuses — it never has to guess whether the copy it cached weeks ago is still current. This is what let the daemon DELETE its slug-keyed cache rather than bolt a TTL onto it. A cache keyed on a mutable name is the part that should not exist; with the content hash as the address, staleness is impossible by construction and verification is free, because the address and the checksum are the same value. Fails open, and says so. If the metadata fetch fails or an older API returns no content, no digest is sent and the CLI prints "this run will be UNVERIFIED" rather than implying a guarantee it did not obtain. Tests: 12 pass / 0 fail. The important ones are cross-language — the digest is computed here in TypeScript and verified in the daemon in JavaScript, so an encoding or normalisation disagreement would fail every run, or worse get "fixed" by weakening the check. Fixtures cover empty scripts, non-ASCII, CRLF and trailing whitespace, and assert that whitespace is NOT normalised away (normalising would give two genuinely different files one address, which is the staleness bug reintroduced through the back door). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DezvuWKN7k5zUBtJ44ZZM9
…just doctor (#182230) parsePlan collapsed any frontmatter `version` that was not EXACTLY the number 2 down to v1, and a v1 plan parses zero steps — so "version: 3", the string "2", 2.0, or a missing field produced a playbook that validated clean, synced clean, and executed nothing. The coercion was only half the bug. `iris playbook doctor` DID catch it, but the check was implemented inline in platform-playbook.ts and only there. `iris playbook test` calls validatePlan(), whose sole step-count rule was gated behind `version === 2` — which a coerced plan never is. Two commands, one file, opposite verdicts; test reported "Steps: 0 / No issues found / Valid" on an 8-step playbook. - SkillPlan gains declaredVersion (raw frontmatter value, pre-coercion) and bodyStepCount (steps parsed from the body regardless of version). parseSteps now always runs; only EXPOSURE as executable steps stays gated on v2. Without both, the parser destroys the evidence before validation can see it: a mis-versioned playbook is otherwise indistinguishable from an honest v1 one. - validatePlan() errors when version !== 2 && bodyStepCount > 0, naming what was actually declared. Silent when bodyStepCount === 0 so genuine v1 prose playbooks stay green. - bodyStepCount is optional: four test files construct SkillPlan literals and a required field would break them for no gain (parsePlan is the only producer). d307ad1 already removed the inline duplicate from platform-playbook.ts, so without this commit HEAD detects the trap in NEITHER command. Verified by reproduction, not inspection: old binary, version: 3 -> Steps: 0, "No issues found", Valid patched, version: 3 -> "8 '### step:' block(s) ...", Validation failed patched, version: 2 -> Steps: 8, Valid Confirmed on the installed binary, and `playbook doctor` over all 69 playbooks reports zero new errors (no false positives on real v1 docs). 5 regression tests; 3 confirmed FAILING with the check disabled — a test that passes either way tests nothing. Suite 205 pass / 0 fail, typecheck clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WcmgfXD81U3cgNv9L2ZzfL
…nent library The compiler has always recorded props/emits/slots on every artifact so a builder need not re-parse source, and the API has always had an index. Nothing surfaced either — so composing a page meant remembering slugs and guessing prop names, which is the friction that makes someone write a NEW component instead of naming an existing one. That quietly defeats the point of a stored library. genesis library list [--search] [--stale] props/emits/slots inline; search the declared API genesis library show <slug> [--source] plus the JSON to paste into a page genesis library usage <slug> which pages name it, nested marked, count stated genesis library versions <slug> publish history + staleness genesis library rollback <slug> --version restore; says every page just changed NAMED library, NOT components, because `genesis components <slug>` already exists and means something different — what is on ONE PAGE. One word with two meanings is the drift that makes a CLI unlearnable, and it is the same trap that had dataset meaning two things earlier today. The product purpose line and keywords were widened too: those are what `iris help` and agents read, so a capability absent from them is undiscoverable no matter how good the verb is. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…jects Filing something in the wrong project is the normal case, not the exceptional one. Until now the only remedy was to recreate the item, which changes its id and breaks every cross-reference and public share URL pointing at it. Pairs with the fl-api change that accepts bloq_id on update, re-homes the item onto a list in the destination, and refuses a move that would drop a PHI/sensitive boundary. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01792epDfkpAJTSXPpK2HmnU
iris bloqs update-item <id> --to-bloq <id> --to-list <id>
THE CAPABILITY ALREADY EXISTED — only the flags were missing. BloqItemController::update
has accepted `bloq_id` and `bloq_list_id` for a while, and its own comment says why:
// Move an item to a different project. There was no way to do this at any layer —
// not the CLI, not the API — so the only way to file something in the right place
// after the fact was to recreate it, which changes its id and breaks every
// cross-reference and public share URL pointing at it.
That last clause is the point. An item's public URL is /n/<uuid> keyed to its id, so
"recreate it in the right bloq" silently breaks every link already shared — which is exactly
the situation this was needed for: four published research items sitting in Published Docs
that belonged in the IRIS Capabilities epic, with their URLs already circulated.
Verified by read-back rather than by the success message, which printed "updated ()" with no
field names and would have looked identical had nothing moved:
#182268 #182278 #182315 #182021
now in bloq 503 / list 2171 ✓ (and gone from 522/1568)
public /n/<uuid> still 200 ✓ all four
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DezvuWKN7k5zUBtJ44ZZM9
The CLI half of #182312. Daemon half: bridge b920bf7. `hive selftest` now emits `ran-on-the-targeted-node` FIRST, because every other assertion is about a machine — and if that one fails, they are all describing the wrong one. MEASURED 2026-08-24: three consecutive runs of `hive selftest MacBookPro` scored 6/8, 0/1 and 4/8 with different failures each time. That was not flakiness. At least one demonstrably executed on a different machine, and at least one demonstrably ran on MacBookPro, so two of those scores describe two different computers. An instrument that cannot say which machine it measured cannot be used to decide anything. A result that does not say which node ran it is a FAILURE, not a pass — that silence is the pre-fix state, and treating it as "probably fine" is exactly how three scores came to describe two machines. Omitted entirely when no target is supplied, so callers that never named a node do not gain a phantom failure. Tests: 18 pass / 0 fail. They cover the match, the mismatch (asserting BOTH machine names appear, since "it ran somewhere else" is useless without saying where), the silent case, the ordering, and the omission. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DezvuWKN7k5zUBtJ44ZZM9
fromHiveTask read `r.output ?? r.stdout`, so the merged field — which every node sends, and which contains BOTH streams — always won. The "streams come back separate" assertion could not pass however correct the node was, because the mapper discarded the separation before the assertion ran. `output` remains the fallback for nodes that predate separated streams. Refs #182004 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DezvuWKN7k5zUBtJ44ZZM9
…you import it Every other discovery surface requires you to already know the answer: `read` needs a function name, `sync` needs a bloq id AND source AND path, `pulse check` needs a keyword. On day one you have none of those. survey is the read-only manifest that comes first — it imports nothing. It reads BOTH the availability list and the connections list and reports where they DISAGREE, because neither alone is trustworthy. That merge immediately found a bug far larger than the one it was written for: on this account 8 of 16 connected sources are invisible to `data-sources list` — google-drive (3 accounts), courtlistener, tradovate, and the entire social estate (instagram/x/tiktok/threads/linkedin, 17 brand accounts). All are callable via `read`; none are discoverable. Anyone asking "what data do I have" gets an answer wrong by half. Filed as #182323, scope corrected after this ran. Also reports two numbers that are usually different — sources connected vs sources `sync` can actually bulk-ingest (1 of 16 here, since sync only accepts dropbox|google_drive). Conflating those is the mental-model error this exists to prevent. --deep counts what is inside each enumerable source, and records WHY a count failed rather than rendering a blank: "HTTP 500", "requires: vault", "Missing required parameters: query" are all different from "0 items". Also fixes #182326 in the same file: `data-sources list --json` and `read --json` printed the UI banner before the JSON, so stdout would not parse (`Expecting value: line 1 column 1`). Same defect fixed in `playbook verify` earlier today; same fix — gate all chrome behind `if (!json)`, no trailing outro on the JSON path. 18 new tests over the pure helpers, including a regression test built from the real production shape (connected + working + unlisted) and one for the underscore/hyphen spelling split between `sync` (google_drive) and everything else (google-drive), which a naive string compare would double-count. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DkFsjYp3XWwDQBeTH6cEqi
Task ids are UUIDv7 — time-ordered, so runs a second apart share a leading prefix. Three consecutive selftests printed "task 01a0370c" and read as one cached result being replayed; they were three distinct tasks whose ids differed only after the eighth character. That is the same truncated-uuid mistake that produced a wrong high-severity diagnosis in #182312, reproduced by the tool built to catch exactly this class of defect — a display that cannot distinguish two runs from one. Source only: installing it is blocked on disk (volume is 100% full). Refs #182004, #182312 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DezvuWKN7k5zUBtJ44ZZM9
…a playbook The record → draft → publish pipeline was complete except for its first and last links, and both were small. DISCOVERABILITY: mic capture already existed, well-built (level meter, silence warning, keeps the wav when transcription fails) — as `iris listen`/`dictate`. Nobody hunting for "record" guesses either, so the capture step read as missing when it was only misnamed. `record` is now an alias. THE CHAIN: `playbook draft` accepted a transcript all along, but listen transcribed, printed, and DELETED its audio, leaving the user to find the file and re-invoke by hand. --draft (and --sop) now pin the transcript to a known path and hand it to the real drafter — no re-transcription, one drafter, same behaviours. The wav is kept until the draft actually succeeds: a failed draft must never be why the recording is gone. `agent` IS A REAL MODE NOW. The server's WalkthroughStructurer emits `mode: agent` for every step of a drafted playbook, deliberately — a step a model extracted from audio must not be runnable on sight (measured: "bare push" came back as "bear push"), so promoting one to shell is a human edit where someone takes responsibility for what runs. That is good design, but `agent` was never in StepDef's union: it worked only by falling through the executor's `default:` to manual. So validation flagged EVERY drafted playbook as broken — including live-meeting-to-build-pipeline, which is what prompted this. Declaring it keeps the runtime behaviour identical, makes the intent visible instead of accidental, and stops the checker crying wolf on the platform's own output. Verified live: the failure path warns on silence, refuses to draft from an empty transcript, and preserves the recording; the drafter produces an accurate 5-step playbook from a transcript and now passes `playbook doctor` clean. Found and filed while testing, not fixed here: `playbook draft --name` renames the directory but not the frontmatter `name:`, so the result is unaddressable by the name you gave it (#182332). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DkFsjYp3XWwDQBeTH6cEqi
…ion, genesis-regression The index had fallen behind `iris data-sources survey` (7427e64), which is committed, plus the playbook-composition how-to and the genesis-regression playbook/skill. A capability that is not indexed is one agents cannot discover, so a stale index is a silent feature outage rather than a bookkeeping lapse — which is why the pre-push hook guards it. Purely additive: verified that no existing capability name is removed by the regeneration. The other changed lines are haystack/count fields. Regenerated with plain `bun run capabilities`, NOT --prune: five indexed entries have no source in this workspace and belong to a machine that has them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DezvuWKN7k5zUBtJ44ZZM9
…ething Widening always confirms. Narrowing never needs to. Without a terminal, widening is REFUSED rather than skipped. The two directions are not symmetric and must not carry symmetric friction. Narrowing is recoverable — the link 404s, someone says they cannot get in, you widen it again, and nothing escaped. Widening cannot be taken back: once a URL is fetched it can be cached, indexed and forwarded, and making it private afterwards un-sends nothing. Five subsystems had each grown their own answer and all five got it wrong in the same direction. `genesis visibility` actually confirmed when RESTRICTING and sailed straight through when going public (#182345) — the guard was attached to the cheap-to-undo branch. Its warning was good information, so it stays; only the blocking moved to the direction that earns it. The sharper half was `if (!args.yes && !isNonInteractive())`: no TTY, no prompt, proceed. That gave an automated caller LESS friction than a person, when the agent is precisely the caller who cannot see the consequence. Headless now refuses and names the flag. New src/cli/cmd/exposure-gate.ts — one ladder (private → team → gated → unlisted → public), one decision point, every command delegates. Wording comes from the rung: a share carrying --password or --allowed-domains lands on `gated` and does not claim internet exposure it does not cause. Gated now, all verified against the rebuilt binary: genesis visibility <slug> <mode> --force atlas make-public / atlas:item make-public --force atlas:item publish --public / bloqs publish --force-public playbook publish --scope public --force datasets feeds create --force `atlas:item publish --force` already means "overwrite an item edited in the UI" (#154763), so exposure consent there is --force-public. Overloading one flag with two unrelated meanings would be its own bug; the asymmetry is deliberate. exposure-gate.test.ts, 28 tests: seven narrowings asserted silent, seven widenings asserted refused. Re-inverting the guard turns this red — which is the point, since the original defect was an inversion and not an omission. Measured end to end on a scratch page: narrow → private no prompt, applied widen → public REFUSED, exit 1, both URLs named, still private after widen --force applied Full CLI suite 669 pass / 0 fail. argv-mapping.test.ts caught the new flags as registered-but-never-read on the way — `(args as any).force` is invisible to it, and the cast was unnecessary. Epic #182344 · G-03, G-04, G-10. Refs #182345. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019hMDLWpTgPLX5LjZPPA7wz
…asserting it
`iris playbook publish --scope private` promised "private (you)" and nothing
anywhere verified it. Genesis has had `check-public` for pages for a while; the
playbook side had no equivalent, so "private" was a claim the CLI made about the
server rather than an observation of it.
That matters more than a normal missing test because the failure is asymmetric.
If publishing breaks, the author sees an error. If PRIVACY breaks, the author
sees exactly what success looks like: the command succeeds, no URL is printed,
and the content is on the internet.
`iris playbook check-private <name>` runs two probes, both unauthenticated ON
PURPOSE — requireAuth() is deliberately not called, because the question is what
an anonymous caller gets and answering it with a credential attached is the
mistake the command exists to prevent:
1. GET /api/v1/playbooks/{name} → must not be 200
2. GET /api/v1/playbooks → the name must not appear
Being LISTED counts as a failure even when the body is withheld: playbook names
carry client and project information, so leaking that one exists is a disclosure
on its own.
The verdict is a pure function (`privacyVerdict`) so it is testable without a
network, and the rule worth pinning is that UNMEASURED IS NOT SAFE — if the
public list cannot be reached we do not know, and "could not check" must never
render as "private". 7 tests cover that plus every readable/listed combination.
Measured against the live API: genesis-regression (private) → 404 and absent
from a 26-row anonymous list, so the claim HOLDS. health-check (public) → 200,
2909 bytes, listed. The check can fail, which is the property that makes it
worth having.
Epic #182344 · G-11. Refs #182346.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019hMDLWpTgPLX5LjZPPA7wz
…ross nouns Epic #182344 G-09. The same question had five answers depending on which noun you happened to be holding: pages visibility, atlas make-public, playbook publish --scope, datasets feeds, and nothing at all for components. None was a superset of the others, so there was no most-capable surface to standardise on. iris exposure show <ref> page:<slug> · note:<id> · playbook:<name> iris exposure audit everything a stranger can currently reach iris exposure narrow <ref> never asks — closing a door is recoverable iris exposure widen <ref> always confirms, refuses without a terminal The address grammar deliberately mirrors Genesis collection addresses (item:, list:, bloq:) so the product has ONE addressing idea rather than two. A bare value is inferred — digits are a note, anything else a page — and the inference is reported rather than silent, because inference you cannot see is how you answer a question nobody asked. pageTier() makes the gate outrank the visibility column. /p/exposure-architecture is visibility=public with an OTP gate in front, and a stranger gets nothing; reporting "public" would be true about a column and false about the world. Running the audit for the first time found 102 things reachable by a stranger. The epic had been reasoning about 21 — the notes — because that was the only surface with a command that could enumerate. 58 PAGES are public and nobody had ever counted them, including dev artefacts (composition-proof, graph-neighbourhood-proof, atlas-console-chat). That gap between "what we discussed" and "what is actually open" IS the finding, and it existed because no single command could ask. The scan states its own bounds — the note walk covers 50 boards, the page list caps at 200 — because a capped scan reported as a total is a confident partial answer, and this epic exists to remove exactly those. collectPublishedItems() extracted from executeListPublished so `exposure audit` and `atlas:item list` cannot drift into two different answers to "what is public?". 17 tests: address parsing, unknown-kind refusal, and every pageTier case including gate-beats-visibility and draft-is-not-public. Suite 686 pass / 0 fail. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019hMDLWpTgPLX5LjZPPA7wz
… (#182461) `iris leads search` asked one question — "is there a row in the leads table matching this string?" — and reported the answer as though it answered a much bigger one: "do we know this person?" Those come apart the moment someone is real but was never entered as a lead. Tyler Smith (Flo) is on the team and is written about across four boards; every lookup returned "No leads matching", which reads as *we have nothing on them*. Richard Delgado resolved only because a CRM row happened to exist (anomalyco#15743) — not because the lookup was any better at finding people. The cross-project endpoint (`bloqs/content-items`) was reachable the whole time. Nothing called it from here. Two places assumed a person exists only if a CRM row does: 1. leads search queried /api/v1/leads and nothing else. 2. federatedSearch's bloq source REQUIRED a bloqId and skipped itself with "no bloq context" without one — so the search that advertised covering everything you have written could not answer "where have I seen this name" unless you already knew which project to look in. Now: both sources run concurrently, ON BY DEFAULT (`--crm-only` opts out). A flag you have to know about would have left the default answer exactly as wrong as it was. bloqId becomes an optional narrowing; only an unresolved USER is a real skip. Evidence is a LABEL plus raw counts — crm+mentions / crm-only / crm-partial / mentions-only / none — not a 0-100 score. A number implies a calibrated model; this is two sources and a count, and dressing that up as "confidence: 72" is the false precision that has bitten every other instrument in this repo. Verified live, not grepped: "tyler smith" 0 results -> 15 mentions / 4 projects, incl. #182253 "Call with Rashad Bernard & Tyler Smith" "richard delgado" lead only -> lead + 25 mentions / 9 projects Two things the live run then exposed, both fixed here: - The one-word fallback surfaced ten unrelated @tyler_* handles looking exactly like answers, under a spinner reading "0 CRM result(s)". They are labelled `crm-partial` now and the count describes what is on screen. - The first cut printed "nothing written about them in any project" under --crm-only — asserting a sweep that never ran. That is #182461 reintroduced inside the patch for #182461. gradeEvidence now takes mentionsSearched, and a test pins that an unsearched source never reads as an empty one. 20 tests, typecheck clean, binary built and exercised in a real terminal. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BBkMsXfzoNugJXsrDC3jUJ
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
buildBespokeJsonContent()hardcodedrequireOtp:falseon the standalone lane and omitted it on the custom lane, regardless of--requires-auth. Therequires_authrecord column locked the page, but every visitor (including the owner) got the frictionless "instant access, no code, no password" modal instead of a real emailed code — and that path has no code to submit, so it looped./p/mediguide-boundary: the page owner could not get past the email step.requiresAuthintobuildBespokeJsonContent()and settingrequireOtpfrom it on both lanes.Test plan
bun test src/cli/cmd/platform-pages-verify.test.ts— 21/21 pass, including 2 new regression testsjson_content.requireOtp = trueon the affected page, then ran the real flow end to end —send-otp(200), code received via email,verify-otp(200, real session token), gated content unlocked with the token, anonymous request still correctly shows the OTP-mode gate (requireOtp":truein the SSR payload)Fixes #182059.
🤖 Generated with Claude Code
https://claude.ai/code/session_01HzGwGaksRciQ6AWi4y7WW3